| 1 | import { Stack, router, useLocalSearchParams } from "expo-router"; |
| 2 | import * as WebBrowser from "expo-web-browser"; |
| 3 | import { ExternalLink, Send } from "lucide-react-native"; |
| 4 | import { useState } from "react"; |
| 5 | import { View } from "react-native"; |
| 6 | |
| 7 | import { Body, IssueIcon, PullIcon, Timeline } from "@/components/code"; |
| 8 | import { Button, ErrorView, Field, Loading, Row, Screen, SectionHeader, Text } from "@/components/ui"; |
| 9 | import { useComment, useIssue } from "@/lib/code"; |
| 10 | import { siteUrl } from "@/lib/server"; |
| 11 | import { useSignedIn } from "@/lib/session"; |
| 12 | import { ago } from "@/lib/time"; |
| 13 | import { space } from "@/theme"; |
| 14 | |
| 15 | /** An issue: what it asks for, the pull requests made for it, and its conversation. */ |
| 16 | export default function Issue() { |
| 17 | const params = useLocalSearchParams<{ owner: string; repo: string; number: string }>(); |
| 18 | const number = Number(params.number); |
| 19 | const { server } = useSignedIn(); |
| 20 | const issue = useIssue(params.owner, params.repo, number); |
| 21 | const comment = useComment(params.owner, params.repo, number); |
| 22 | const [draft, setDraft] = useState(""); |
| 23 | |
| 24 | if (issue.isPending || issue.error) { |
| 25 | return ( |
| 26 | <Screen> |
| 27 | <Stack.Screen options={{ title: `#${number}` }} /> |
| 28 | {issue.error ? <ErrorView error={issue.error} retry={() => issue.refetch()} /> : <Loading />} |
| 29 | </Screen> |
| 30 | ); |
| 31 | } |
| 32 | |
| 33 | const { issue: item, pulls, comments } = issue.data; |
| 34 | const by = item.author.display_username ?? item.author.username; |
| 35 | |
| 36 | return ( |
| 37 | <Screen scroll> |
| 38 | <Stack.Screen options={{ title: `#${number}` }} /> |
| 39 | <View style={{ gap: space.sm }}> |
| 40 | <Text size="heading">{item.title}</Text> |
| 41 | <View style={{ flexDirection: "row", gap: space.sm, alignItems: "center" }}> |
| 42 | <IssueIcon issue={item} /> |
| 43 | <Text size="small" tone="muted"> |
| 44 | {item.state === "open" ? "Open" : "Closed"} · {by} · {ago(item.created_at)} |
| 45 | {item.labels.length ? ` · ${item.labels.join(", ")}` : ""} |
| 46 | </Text> |
| 47 | </View> |
| 48 | </View> |
| 49 | <Body text={item.body} /> |
| 50 | {pulls.length ? ( |
| 51 | <View style={{ marginHorizontal: -space.lg }}> |
| 52 | <SectionHeader title="Pull requests" /> |
| 53 | {pulls.map((pull) => ( |
| 54 | <Row |
| 55 | key={pull.id} |
| 56 | leading={<PullIcon pull={pull} />} |
| 57 | title={pull.title} |
| 58 | subtitle={`#${pull.number} · ${pull.status}`} |
| 59 | onPress={() => |
| 60 | router.push({ pathname: "/code/[owner]/[repo]/pull/[number]", params: { owner: params.owner, repo: params.repo, number: String(pull.number) } }) |
| 61 | } |
| 62 | /> |
| 63 | ))} |
| 64 | </View> |
| 65 | ) : null} |
| 66 | <Timeline comments={comments} /> |
| 67 | <View style={{ gap: space.sm }}> |
| 68 | <Field label="Comment" placeholder="Add to the conversation" value={draft} onChangeText={setDraft} multiline /> |
| 69 | <Button |
| 70 | title="Comment" |
| 71 | icon={Send} |
| 72 | busy={comment.isPending} |
| 73 | disabled={!draft.trim()} |
| 74 | onPress={() => comment.mutate(draft.trim(), { onSuccess: () => setDraft("") })} |
| 75 | /> |
| 76 | {comment.error ? <Text tone="danger">{comment.error.message}</Text> : null} |
| 77 | </View> |
| 78 | <Button |
| 79 | title="Open on g1t" |
| 80 | variant="secondary" |
| 81 | icon={ExternalLink} |
| 82 | onPress={() => WebBrowser.openBrowserAsync(siteUrl(server, `/${params.owner}/${params.repo}/issues/${number}`))} |
| 83 | /> |
| 84 | </Screen> |
| 85 | ); |
| 86 | } |